/*
Copyright (C) 2012 Brady Vidovic

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package xmltvdb;

import java.util.Date;
import org.codemonkey.simplejavamail.TransportStrategy;
import org.codemonkey.simplejavamail.Mailer;
import javax.mail.Message;
import org.codemonkey.simplejavamail.Email;
import btv.logger.BTVLogLevel;
import java.io.FileWriter;
import java.nio.file.Paths;
import java.nio.file.WatchEvent;
import java.nio.file.Path;
import btv.folderwatcher.*;
import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Future;
import org.json.JSONArray;
import org.json.JSONObject;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.jdom.*;
import org.jdom.output.Format;
import org.jdom.output.XMLOutputter;
import xmltvdb.tools.Constants;
import xmltvdb.tools.CachedHTTP;
import xmltvdb.tvdb.Episode;
import xmltvdb.tvdb.TVDB;
import xmltvdb.tvdb.TVDBSeries;
import static java.nio.file.StandardWatchEventKinds.*;
import static xmltvdb.tools.Constants.*;
import static btv.tools.BTVTools.*;

/**
 * This class listens for new .xml files in a folder and
 * if they are XMLTV files, they will be enhanced by adding
 * Season/Episode/OriginalAirDate info from TheTVDB.com
 * @author Brady Vidovic
 */
public class xmltvdb extends Constants implements Runnable
{    
    public static final boolean SINGLE_INSTANCE = true;
    public static final int SINGLE_INSTANCE_PORT = 55200;                
    public static final int SINGLE_INSTANCE_TIMEOUT_SECONDS = -1;//never timeout. Always force single instance
        
    File watchFolder, outputXMLFile;
    public static void main(String[] args)
    {                                   
        xmltvdb x = new xmltvdb();                      
    }    
    
    
    private Future future;
    private CachedHTTP HTTP;
    private File stopperFile;
    private boolean currentlyProcessing = false;
    public xmltvdb()
    {               
        //first thing we need to init Constants class / logger
        super("XMLTVDB.properties", LOGGING_LEVEL, SINGLE_INSTANCE, SINGLE_INSTANCE_PORT, SINGLE_INSTANCE_TIMEOUT_SECONDS);                                               
        try
        {                                                                                          
            this.HTTP = new CachedHTTP();
            
            this.watchFolder = new File(Config.WATCH_FOLDER);
            this.outputXMLFile = new File(Config.OUTPUT_XML_FILE);

            if(!watchFolder.isDirectory())
            {
                if(TESTING)
                {
                    Logger.WARN("Since TESTING = "+ TESTING +" and watch folder ("+watchFolder+") is not valid, using BASE_DIR as watch folder: "+ BASE_DIR);
                    watchFolder = BASE_DIR;
                }
                else
                {
                    Logger.ERROR( "Watch folder is not a valid directory: "+ watchFolder.getAbsolutePath());
                    return;
                }
            }                                                
            
            future = listen(watchFolder);                               
        }
        catch(Exception x)
        {
            if(Logger != null)
                Logger.ERROR("General Error: "+ x,x);
            
            exit(1); //this is an abnormal termination.
                            //normal termination is done by calling stop() method            
        }                  
    }
                        
    
    public final Future listen(File dir) throws IOException
    {
        //Start a background thread to notify us when xmltv files are old (indicated problem with user's XMLTV generator)
        Thread staleXMLTVChecker = new Thread(this);
        staleXMLTVChecker.start();

        Logger.NOTICE("Listening for changes in directory: " + dir);
        
        stopperFile = new File(dir, STOP_FILE_NAME);
        if(!stopperFile.exists()) 
            stopperFile.createNewFile();
        
        return FolderWatchers.getInstance().addFolderListener(Paths.get(dir.getAbsolutePath()),
            new ChangeListener(ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) {
                @Override
                public void onEvent(WatchEvent<Path> event, Path child) 
                {
                    try
                    {
                        WatchEvent.Kind<?> kind = event.kind();
                        File f = child.toFile();

                        if(kind == ENTRY_CREATE)
                        {                                            
                            if("xml".equalsIgnoreCase(FilenameUtils.getExtension(f.getName())))
                            {
                                Logger.NOTICE("LISTENER: Processing xml file: "+ f.getName());
                                lastXmlTvRecieved = System.currentTimeMillis();
                                staleXMLTVFile = false;
                                                                
                                long size  = f.length();
                                long modified = f.lastModified();
                                Thread.sleep(5 * 1000);//initial wait for any locks to be released
                                
                                //make sure it's done being written
                                while(true)
                                {
                                    long newSize = f.length();
                                    long newModified = f.lastModified();
                                    
                                    if(newSize != size || newModified != modified)
                                    {                                       
                                        Logger.INFO("The file is still being written, will wait until it's done."+LINE_BRK+
                                                "Size     = "+ newSize + " bytes"+LINE_BRK+
                                                "Modified = "+ new Date(newModified));
                                        size = newSize;
                                        modified = newModified;
                                        Thread.sleep(5*1000);
                                    }
                                    else
                                        break;
                                }                                                                
                                                                
                                try
                                {
                                    currentlyProcessing = true;
                                    processXMLTVFile(f);
                                }
                                catch(Exception x)
                                {
                                    Logger.ERROR( "General error processing XMLTV File: "+ x,x);
                                }
                                finally
                                {
                                    currentlyProcessing = false;
                                }
                            }
                            else
                                Logger.INFO("LISTENER: Skipping non-xml file: "+ f.getName());
                        }
                        else if(kind == ENTRY_DELETE)
                        {                                
                            if(stopperFile.equals(f))
                            {
                                Logger.NOTICE("LISTENER: Ending listener because StopFile was deleted : "+ f.getAbsolutePath());
                                stop();
                            }
                        }
                        else
                        {
                            Logger.DEBUG("LISTENER: Skipping Event " + kind + ". File is: " + f.getName());
                        }
                    }
                    catch(Exception x)
                    {
                        Logger.WARN( "LISTENER: Failed to process an event: "+x,x);
                    }
                }
            }
        );              
    }
    
    private void exit(int exitCode)
    {
        if(HTTP != null)
            HTTP.cleanCache();      
        
        if(Logger != null)
        {
            emailNotification();
            Logger.close();
        }
        
        System.exit(exitCode);
    }
    
    private void emailNotification()
    {
        if(Config.EMAIL_NOTIFICATIONS)
        {
            if(Logger != null)
                Logger.emailIf(
                    Config.EMAIL_IF, 
                    BTVLogLevel.INFO, 
                    Config.SMTP_SERVER, 
                    Config.SMTP_PORT,
                    Config.SMTP_USES_SSL,
                    Config.SMTP_USERNAME,
                    Config.SMTP_PASSWORD,
                    Config.EMAIL_FROM_ADDRESS, 
                    Config.NOTIFICATION_RECIPIENTS, 
                    "XMLTVDB "+ Config.EMAIL_IF
            );
        }
        else 
            Logger.DEBUG("Not sending e-mail notification because it is disabled in config file.");
        
    }
    
    /**
     * Stops listening and exits.
     */   
    public void stop()
    {
        Logger.INFO("Stopping...");
        boolean success = future.cancel(false);
        Logger.INFO("Stop request " + (success ? "was successful":"failed"));
        if(currentlyProcessing)
            Logger.INFO("Will end as soon as currently processing XMLTV file is processed.");
        
        int timeoutSecs = 5 * 60;
        int count = 0;
        while(currentlyProcessing)//wait until done before exiting        
        {
            try{Thread.sleep(1000);}catch(Exception ignored){}
            if(++count == timeoutSecs)
            {
                Logger.ERROR( "Timed out while waiting to stop. Will force stop now...");
                if(stopperFile.isFile())stopperFile.delete();                
                exit(1);
            }
        }
        
        //done processing
        if(stopperFile.isFile())stopperFile.delete();                          
                
        exit(0);
    }
        
    private synchronized void processXMLTVFile(File xmlFile)
    {   
        HTTP.cleanCache();//clean out any cached xml or HTTP requests before starting        
        Logger.reset();//cleans historical logs and resets current logs
        Logger.NOTICE("Processing: "+ xmlFile.getAbsolutePath());
        
        //move the xmlFile to the default XMLTV's directory (where the dtd is also located)
        File inputXMLFile = new File(XMLTVS_DIR.getAbsoluteFile()+SEP+"xmltv.xml");
        try
        {                                             
            if(inputXMLFile.isFile()) inputXMLFile.delete();//delete destination if it exists
            FileUtils.copyFile(xmlFile,inputXMLFile);

        }catch(IOException x){
            Logger.ERROR( "Failed to move xml file to "+ XMLTVS_DIR.getAbsolutePath() +" directory...",x);
            return;
        }          
        
        try
        {
            Document xmltv = getXMLFromFile(inputXMLFile);
            Element root = xmltv.getRootElement();                                                                                               
            
            Set<String> whatToLookUp = null;
            if(Config.ONLY_LOOKUP_SCHEDULED_SHOWS)
            {                
                
                // 82 = Recording  The programs in the schedule should be recorded.  
                // 83 = Suggestion The programs in the schedule can be shown as suggestions.  
                // 65 = Alert The programs in the schedule should pop up alerts when they are about to start.  
                final int scheduleType = 82;

                String upcomingURL = Config.FTR_WEBSERVICE_URL+"/ForTheRecord/Scheduler/UpcomingPrograms/"+scheduleType;
                Logger.INFO("Getting upcoming scheduled recordings from FTR: "+ upcomingURL);                
                String json  = CachedHTTP.getResponseAsString(HTTP.POST(upcomingURL, null, CachedHTTP.createHeaders("Content-Type", "application/json")));
                
                //Logger.DEBUG("Json returnd from UpcomingPrograms is: "+LINE_BRK+ json);
                JSONArray arr = new JSONArray(json);
                Logger.INFO("Found: "+ arr.length() +" scheduled recordings...");
                /*Sample array
                  [
                      {
                        "AlreadyQueuedProgramId": null,
                        "CancellationReason": 0,
                        "Category": "Series",
                        "Channel": {
                            "BroadcastStart": "",
                            "BroadcastStop": "",
                            "ChannelId": "875bb4a7-98ac-425e-86cc-47c64c373b93",
                            "ChannelType": 0,
                            "DefaultPostRecordSeconds": null,
                            "DefaultPreRecordSeconds": null,
                            "DisplayName": "KQDS-DT",
                            "GuideChannelId": "18d6f0ae-4c4c-463c-aae4-cd25b5e5e0b7",
                            "LogicalChannelNumber": null,
                            "Sequence": 0,
                            "Version": 2,
                            "VisibleInGuide": true
                        },
                        "DescriptionChecksum": "5B-D8-2B-FA-DB-15-37-D7-9E-37-79-55-CE-88-B2-03",
                        "EpisodeNumberDisplay": "112",
                        "Flags": 6,
                        "GuideProgramId": "d064b183-244a-e111-8167-4061862f09ae",
                        "IsCancelled": false,
                        "IsPartOfSeries": true,
                        "IsPremiere": false,
                        "IsRepeat": false,
                        "PostRecordSeconds": 0,
                        "PreRecordSeconds": 0,
                        "Priority": 1,
                        "Rating": "TV-14",
                        "ScheduleId": "61c5a703-fb96-4572-b09e-f34df8cbcf0f",
                        "StarRating": null,
                        "StartTime": "\/Date(1328666400000-0600)\/",
                        "StopTime": "\/Date(1328668260000-0600)\/",
                        "SubTitle": "The Landlord",
                        "Title": "New Girl",
                        "UpcomingProgramId": "573f0524-bce6-a34f-07ab-07a7ca18323d"
                    },
                    ...
                   ]
                 */

                //use title as key to identify a program
                //don't base it on individual episodes, because once we query one episode,
                //we already have the entire series's XML cached from the TVDB.com,
                //making it uncostly to query other episodes from same series
                whatToLookUp = new HashSet<>();                
                for(int i=0;i<arr.length();i++)
                {
                    JSONObject obj = arr.getJSONObject(i);

                    String title = obj.getString("Title");//aka series                
                    //String subtitle = obj.getString("SubTitle");
                    //if(!valid(subtitle))
                        //subtitle = "";//normalize as empty string when subtitle is missing
                    //Logger.log(title + ": "+ subtitle);
                    whatToLookUp.add( (title/*+": "+subtitle*/).toLowerCase() );//normalize as L.C.                
                }
            }                                    
            
            List<Element> programs = root.getChildren("programme");
            String sourceInfoName = root.getAttributeValue("source-info-name");
            boolean isZap2It = valid(sourceInfoName) && Config.ZAP2IT_SOURCE_INFO_NAMES.contains(sourceInfoName.toLowerCase());
            if(isZap2It)
                Logger.NOTICE("This XMLTV source ("+sourceInfoName+") appears to originate from Zap2It");
            else
                Logger.INFO("This XMLTV is not a Zap2It source: "+ sourceInfoName==null ? "[no source-info-name found]" : sourceInfoName);
            
            HashMap<String,SeriesTracker> seriesTrackers = new LinkedHashMap<>();
            int totalPrograms = programs.size();
            int successCount = 0, failCount =0;
            int progCount =0;
            programSearch:for(Element programElem : programs)
            {
                progCount++;
                
                Element titleElem = programElem.getChild("title");
                if(titleElem == null)
                {
                    Logger.ERROR( "Skipping program #"+progCount+" with no <title> element");
                    continue programSearch;
                }
                
                String seriesName = titleElem.getText();        
                if(!valid(seriesName))
                {
                    Logger.WARN( "No series name found from <title> element, skipping a program #"+progCount+"...");
                    continue programSearch;
                }
                
                //check for over-ride based on series name
                String overRiddenSeriesName = Config.TVDB_TRANSLATIONS.get(seriesName.toLowerCase());
                                
                boolean isMovie = false;
                List<Element> categories = programElem.getChildren("category");
                for(Element catElem : categories)
                {
                    String category = catElem.getText();
                    if(Config.EXCLUDED_CATEGORIES.contains(category.toLowerCase()))
                    {
                        Logger.DEBUG( "Skipping program because its category is excluded ("+ category+"): "+ seriesName);
                        continue programSearch;
                    }
                    
                    if(!isMovie)//if any category matches, then its considered a movie
                        isMovie = Config.MOVIE_CATEGORIES.contains(category.toLowerCase());
                }                
                                
                Element subTitleElem = programElem.getChild("sub-title");//the episode title
                String episodeTitle = subTitleElem==null || !valid(subTitleElem.getText()) ? "" : subTitleElem.getText();//if missing, default is empty string
                                               
                Element dateElem = programElem.getChild("date");                                                
                /*
                 * All dates and times in this DTD follow the same format, loosely based
                    on ISO 8601.  They can be 'YYYYMMDDhhmmss' or some initial
                    substring, for example if you only know the year and month you can
                    have 'YYYYMM'.  You can also append a timezone to the end; if no
                    explicit timezone is given, UTC is assumed.  Examples:
                    '200007281733 BST', '200209', '19880523083000 +0300'.  (BST == +0100.)
                 */                
                SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
                String date = dateElem == null ? null : dateElem.getText();
                
                Logger.DEBUG("Program "+ progCount +"/"+totalPrograms+": "+seriesName + " - "+ episodeTitle + 
                        (valid(date) ? " ("+date+")" : ""));
                
                if(!isMovie)
                {
                    //filter by scheduled tv series if configured to do so
                    if(Config.ONLY_LOOKUP_SCHEDULED_SHOWS)
                    {
                        //based on series name only instead of episode title too since series queries are cached
                       if(!whatToLookUp.contains( (seriesName/* +": "+episodeTitle*/).toLowerCase()))//make sure we want to look this up
                       {
                           Logger.DEBUG( "Skipping because this series is not in recording schedule: "+ seriesName);
                           continue;
                       }
                    }

                    //cant process on series name alone. We need either episode title or air date or both
                    if(!valid(episodeTitle) && !valid(date))
                    {
                        Logger.DEBUG( "Skipping because no episode title or airdate is available for airing: "+ seriesName);
                        continue;
                    }
                }
                                                                
                //use language from title elem
                String lang = titleElem.getAttributeValue("lang");
                
                //get description
                Element currentDescElem = findChildElementByAttributeValue(programElem,"desc", "lang", lang);
                String currentDesc = "";
                if(currentDescElem != null)                        
                    currentDesc = currentDescElem.getText();//original desc
                
                String strOrigAirDate = null;
                if(isMovie)
                {//for movies, just try to find year and add it to the description elem
                    try
                    {
                        if(valid(date) && date.length()>=4)
                        {//this appears to be a year, confirm it is an int                        
                            String fourDigitYear = date.substring(0,4);//trim month/day info it it's there
                            int y = Integer.parseInt(fourDigitYear); ///throwx exception for non-integer  
                            if(y < 1800 || y > 2100)//sanity check
                                throw new Exception("Year is too old or too future: "+ y);
                            
                            //if we got here, we know it is 4 numeric digits between 1800-2200. Assume its a valid year.
                            strOrigAirDate =  y+"";
                        }
                        else 
                            throw new Exception("Not a valid year: "+ date);
                        
                        String additionalInfo =" [y:"+strOrigAirDate+"]";
                        String newDesc = currentDesc + additionalInfo;
                        updateOrCreateElem(programElem, "desc", "lang", lang, newDesc);       
                        Logger.INFO("Added: "+ additionalInfo +" to end of desc element for movie: "+ seriesName);

                    }
                    catch(Exception x)
                    {
                        Logger.WARN("Not able to add year to description for movie. Year found = "+ date+". "+x.getMessage());
                    }
                    finally
                    {
                        continue programSearch;//we don't need to do anything else for movies
                    }    
                }
                else//not a movie, assume tv episode
                {                    
                    if(valid(date))
                    {
                        try{
                            strOrigAirDate = TVDB.dateToTVDBString(sdf.parse(date));
                        }catch(ParseException x)
                        {
                            Logger.WARN( "Not using upparsable date: "+ date);
                            strOrigAirDate = null;
                        }
                    }                                                        
                }
                
                //once we get here, movies have already been handled. Should be only episodes                
                                                                                
                
                //passed all checks, try and look it up on TheTVDB.com                                        
                SeriesTracker seriesTracker = seriesTrackers.get(seriesName);
                if(seriesTracker == null)
                {            
                    seriesTracker = new SeriesTracker(seriesName);
                    seriesTrackers.put(seriesName, seriesTracker);                    
                }
                
                String knownTVDBId = null;
                if(valid(overRiddenSeriesName))
                {                    
                    //if it is a number, assume it is a TVDB id instead of a series name
                    try{
                        knownTVDBId=Integer.parseInt(overRiddenSeriesName)+"";
                        overRiddenSeriesName=null;//this is an ID, not a series name
                        Logger.INFO("Using over-ridden TVDB SeriesID: \""+knownTVDBId+"\" for series: \""+seriesName+"\"");
                    }
                    catch(NumberFormatException notATVDBId){
                        Logger.INFO("Using over-ridden series name: \""+overRiddenSeriesName+"\" for series: \""+seriesName+"\"");
                    }                    
                }
                                
                //create an Episode object with what we know fromt he XMLTV data.
                TVDBSeries tvdbSeries = new TVDBSeries(knownTVDBId,(valid(overRiddenSeriesName) ? overRiddenSeriesName : seriesName),null);
                Episode guideEpisode = new Episode(tvdbSeries);                
                guideEpisode.setLanguage(lang);
                if(valid(strOrigAirDate)) guideEpisode.setOriginalAirDate(sdf.parse(date));
                if(valid(episodeTitle)) guideEpisode.setTitle(episodeTitle);
                
                if(!guideEpisode.hasTVDBid())
                {//try using external id(s)
                    if(isZap2It)
                    {                                            
                        String zap2ItId = getZap2ItId(programElem);
                        if(zap2ItId!=null)
                        {                                                        
                            Logger.DEBUG( "Setting Zap2It id: "+ zap2ItId +" for "+ seriesName);
                            guideEpisode.setZap2ItId(zap2ItId);                                                        
                        }
                    }
                    
                    //TODO: parse IMDB id's (need a sample xmltv file that uses IMDB id's)
                }
                
                boolean successfulLookup;
                try
                {
                    TVDB tvdb = new TVDB(HTTP, guideEpisode);
                    successfulLookup = tvdb.lookup();
                }catch(Exception x){
                    Logger.WARN( "Failed to lookup episode on TheTVDB.com: "+x,x);                    
                    successfulLookup = false;
                }
                
                if(successfulLookup)
                {//inject season/ep info to the xmltv doc
                                        
                    //update onscreen episode num (gets displayed in guide)
                    String displayValue = displayValue = guideEpisode.isMultiPart() ? guideEpisode.getMultipartSeasonEpisodeNaming() : "S"+guideEpisode.getPaddedSeasonNumber()+"E"+guideEpisode.getPaddedEpisodeNumber();
                    updateOrCreateElem(programElem, "episode-num", "system", "onscreen",displayValue);                                                                                
                    
                    //update xmltv_ns episode num 
                    updateOrCreateElem(programElem,"episode-num", "system", "xmltv_ns", guideEpisode.getXMLTVSeasonEpisodeAttribute());                    
                    
                    //update description (used for storing multi-episode details and tvdbid)                                                           
                    String multiPartNaming = guideEpisode.isMultiPart() ? " ["+guideEpisode.getMultipartSeasonEpisodeNaming()+"]" : "";//Like S01E01E02
                    String tvdbId = " [tvdb:"+guideEpisode.getTVDBId()+"]";
                    String seriesYear = valid(guideEpisode.getSeriesYear()) ? " [y:"+guideEpisode.getSeriesYear()+"]" : "";
                    
                    String additionalInfo = multiPartNaming + seriesYear + tvdbId;
                    String newDesc = currentDesc + additionalInfo;
                    updateOrCreateElem(programElem, "desc", "lang", lang, newDesc);       
                    Logger.DEBUG( "Added: "+ additionalInfo +" to end of desc element.");

                    
                    //makes sure the xmltv_ns element is the first episode-num element 
                    //(FTR bug only reads first one... to be fixed in next beta after 1.6.0.1
                    Element xmltv_nsElement = findChildElementByAttributeValue(programElem, "episode-num", "system", "xmltv_ns");
                    int firstEpisodeNumIndex = programElem.indexOf(programElem.getChild("episode-num"));
                    int xmltvNsEpisodeIndex = programElem.indexOf(xmltv_nsElement);
                    
                    if(xmltvNsEpisodeIndex != firstEpisodeNumIndex)
                    {//if we got here, there is an episode-num element that comes before the xmltv_ns episode-num element
                        //move it to the first
                        Logger.INFO("Moving the xmltv_ns episode-num element to the first of the episode-num elements for this proram (Handles FTR Bug)");
                        programElem.removeContent(xmltvNsEpisodeIndex);
                        firstEpisodeNumIndex = programElem.indexOf(programElem.getChild("episode-num"));//reset the index since children have changed
                        programElem.addContent(firstEpisodeNumIndex, xmltv_nsElement);
                        
                    }                    
                    successCount++;
                    seriesTracker.addSuccess();
                }
                else//failed lookup
                {
                    failCount++;
                    seriesTracker.addFail();
                }
                
                Logger.INFO("---"+(successfulLookup?"SUCCESS":"FAILED ")+"--- " +guideEpisode.toString());                                                
                                                                                           
            }//done looping thru guide episodes
            
            XMLOutputter out = new XMLOutputter(Format.getPrettyFormat());
            Logger.NOTICE( "Writing updated XMLTV file to: "+ outputXMLFile);
            try (FileWriter writer = new FileWriter(outputXMLFile)) 
            {            
                out.output(xmltv, writer);            
                writer.close();

                Logger.INFO("Successfully created updated XMLTV file, now renaming original file to .old: "+ xmlFile.getAbsolutePath());
                renameToOld(xmlFile);//success, rename the old file to a .old extension (to prevent processing again)
            }
            catch(IOException x)
            {
                Logger.ERROR( "Failed to write new XML File at: "+ outputXMLFile.getAbsolutePath(),x);
            }


            Logger.NOTICE("********************TVDB LOOKUP SUMMARY**********************");        
            ArrayList<SeriesTracker> seriesTrackerList = new ArrayList<>(seriesTrackers.values());
            Collections.sort(seriesTrackerList);
            
            for(SeriesTracker st : seriesTrackerList)
            {
                Logger.NOTICE(tfl(st.getSucess()+"/"+st.getTotal(),10)+" - "+ st.getSeries());
            }
            Logger.NOTICE("Total: "+successCount +" successful lookups, "+failCount +" failed lookups.");    
            Logger.NOTICE("Of "+progCount +" total programs, "+ seriesTrackers.size() +" unique series were looked up."
                    +LINE_BRK+seriesTrackers.keySet().toString());

            Double totalMb = (HTTP.httpTotalBytes / 1024.0 / 1024.0);
            String strMb = new DecimalFormat("#.##").format(totalMb);
            Logger.NOTICE("Performed "+ HTTP.uncachedQueries +" total TheTVDB.com queries ("+strMb +" MB of data), and "+ HTTP.cachedQueries +" cached TVDB.com queries");                                                        
        }
        catch(Exception x)
        {
            Logger.ERROR( "Error processing xml file: "+x,x);
        }
        finally
        {
            Logger.NOTICE("--------------------------------------------DONE PROCESSING XMLTV FILE--------------------------------------------");        
            emailNotification();        //emails if warning/error (user-configurable)
        }                
    }
    
    private static String getZap2ItId(Element programElem)
    {
        String zap2ItId = null;
        Element zap2ItEpisodeElem = findChildElementByAttributeValue(programElem, "episode-num", "system", "dd_progid");   
        if(zap2ItEpisodeElem != null)
        {
            zap2ItId = zap2ItEpisodeElem.getText();//EP00688359.0258
            if(zap2ItId != null)
            {
                if(zap2ItId.contains("."))//EP00688359.0258
                    zap2ItId = zap2ItId.split("\\.")[0];                            
                else zap2ItId = null;//unkown format
            }                                                                            
        }

        if(zap2ItId == null)//not found, try ms_progid method
        {
            //Example: <episode-num system="ms_progid">1.EP006883590258</episode-num>
            zap2ItEpisodeElem = findChildElementByAttributeValue(programElem, "episode-num", "system", "ms_progid");   
            zap2ItId = zap2ItEpisodeElem.getText();//1.EP006883590258
            if(zap2ItId.contains("."))
            {
                zap2ItId = zap2ItId.split("\\.")[1];
                if(zap2ItId.length() > 10)
                    zap2ItId = zap2ItId.substring(0,10);
                else zap2ItId = null;//unkown format
            }
            else zap2ItId = null;//unknown format                        
        }       
        
        if(zap2ItId != null)
        {
            if(zap2ItId.length() == 10)
                return zap2ItId;//valid id
            else
            {
                Logger.WARN("Found Zap2It id with length of "+ zap2ItId.length()+" ("+zap2ItId+"), epected length of 10. Will not use this id.");
                return null;
            }
        }
        return null;        
    }
    
    private static void updateOrCreateElem(Element parent, String childName, String attributeName, String attributeValue, String newChildValue)
    {                
        //find the child based on the attribute name and value
        Element child = findChildElementByAttributeValue(parent, childName, attributeName, attributeValue);                    
        if(child == null)//doesn't exist... add new one
        {
            child = new Element(childName);
            child.setAttribute(attributeName,attributeValue);
            
            //determine the index of any existing children of the same name
            int episodeElemIndex = parent.indexOf(parent.getChild(childName));
            if(episodeElemIndex < 0) episodeElemIndex=0;
            
            //add to parent
            parent.addContent(episodeElemIndex,child);
        }
        
        //update/set the value
        String origVal = child.getText();      
        if("xmltv_ns".equals(attributeValue) && valid(origVal))
        {
            //check if the original XML already has the part avail
            try
            {
                String[] origParts = origVal.split("\\.");
                if(origParts.length != 3) throw new Exception("Not 3 parts seperated by .");
                
                String partOf = origParts[2].trim();
                if(valid(partOf))//something like 1/3
                {//use existing partOf in the new value
                    String[] newParts = newChildValue.split("\\.");
                    String updatedNewChildVal = "";
                    for(int i=0;i<2;i++)
                        updatedNewChildVal += newParts[i]+".";//use the same first 2 parts
                    //use the orig partOf value for the third part
                    updatedNewChildVal += partOf;
                    
                    Logger.INFO( "Used part/of from orignal guide data. "+newChildValue +" became "+ updatedNewChildVal);
                    newChildValue = updatedNewChildVal;
                }
            }
            catch(Exception ignored)
            {
                Logger.DEBUG( "Could not parse original xmltv_ns value: "+ origVal);
            }
        }
        
        child.setText(newChildValue);
        Logger.DEBUG( "<"+childName+" "+attributeName+"=\""+attributeValue +"\"> - "+ (valid(origVal) ? "Changed from "+ origVal + " to "+ newChildValue : "Set val to: "+ newChildValue));
    }

    //Background thread sends warnings if it has been too long since we last saw a new xmltv file
    long lastXmlTvRecieved = System.currentTimeMillis();
    boolean staleXMLTVFile = false;    
    @Override
    public void run() 
    {
        while(true)
        {
            long msSinceLastXml = System.currentTimeMillis() - lastXmlTvRecieved;
            double hoursSinceLastXml = ( (msSinceLastXml / 1000) / 60.0 / 60.0);
            String strHrs = toFixedDecimal(hoursSinceLastXml, 2);
            
            if(hoursSinceLastXml > Config.MAX_WAIT_HOURS_FOR_NEW_XMLTV)
            {                
                if(!staleXMLTVFile)
                {                    
                    Logger.WARN("It has been "+ strHrs +" since a new XMLTV .xml file was found in: "+ Config.WATCH_FOLDER);
                    if(Config.EMAIL_NOTIFICATIONS)
                    {
                        Email email = new Email();
                        email.setFromAddress(Config.EMAIL_FROM_ADDRESS, Config.EMAIL_FROM_ADDRESS);
                        email.setSubject("No new XMLTV files found for "+ strHrs + " hours!");
                        email.setText("It has been at least "+ strHrs +" since a new XMLTV .xml file was found in: "+ Config.WATCH_FOLDER+LINE_BRK+
                                "Please check and make sure that your XMLTV generator is still functioning correctly.");
                        for(String to : Config.NOTIFICATION_RECIPIENTS)
                            email.addRecipient(to, to, Message.RecipientType.TO);

                        Mailer mailer = new Mailer(Config.SMTP_SERVER, Config.SMTP_PORT, Config.SMTP_USERNAME, Config.SMTP_PASSWORD, 
                                Config.SMTP_USES_SSL ? TransportStrategy.SMTP_SSL : TransportStrategy.SMTP_PLAIN);
                        mailer.sendMail(email);
                        Logger.NOTICE("Successfully sent notification to: "+ Config.NOTIFICATION_RECIPIENTS);
                    }
                    else
                        Logger.NOTICE("Since EMAIL_NOTIFICATIONS = "+ Config.EMAIL_NOTIFICATIONS+", will not send any notifications. Please make sure you XMLTV generator is working properly.");

                    staleXMLTVFile = true;
                }
                //else already sent warning, nothing else to do
            }
            else//within limits. Nothing wrong
            {                
                staleXMLTVFile = false;
            }

            try{
                Thread.sleep(1000 * 60);
            }catch(InterruptedException ignored){}                
        }        
    }
    
    
    
    /**
     * Simple tracking class to track successful and failed look-ups per series.
     */
    private static class SeriesTracker implements Comparable<SeriesTracker> {
        private int sucess = 0, fail =0;
        private String series;
        public SeriesTracker(String series)
        {
            this.series = series;
        }

        public String getSeries() {
            return series;
        }
        
        public void addSuccess(){sucess++;}
        public void addFail(){fail++;}

        public int getFail() {
            return fail;
        }

        public int getSucess() {
            return sucess;
        }
        
        public double getPercentSuccessful(){
            if(getTotal() == 0) return 0.0;
            
            return 100.0 * (((double)getSucess()) / ((double) getTotal()));
        }
        
        public int getTotal(){
            return getSucess()+getFail();
        }

        //order more successful first
        @Override
        public int compareTo(SeriesTracker o) {
            if(this.getPercentSuccessful() > o.getPercentSuccessful()) return -1;
            if(this.getPercentSuccessful() < o.getPercentSuccessful()) return 1;
            return 0;
        }                
    }        
}